本記事は「mybest BlogKaigi 2024」の13日目の記事です。
はじめにマイベストでBackendエンジニアをしている rince です。今回は、Google MapのAPIとElasticsearchを使って位置情報検索を実装した話について書きたいと思います。
背景弊社では自社検証を強みとしてユーザーの"選択"をサポートする商品比較サービス 『マイベスト』 を運営しています。これまでは実際にECで購入できるモノを中心に扱っていたのですが、ここ最近はモノ以外のサービスの比較・検証にも力を入れています。
その中で、英会話、塾、ジム、買取サービス、クリニックなど自分の通える範囲に店舗や施設があるかどうかが重要なカテゴリにおいて、「新宿駅周辺の英会話教室」や「渋谷から通える大学受験塾」など「位置 × カテゴリ」でページを作って、地図上でどこに店舗があるかを見た上で商品(サービス)を選びたいというニーズが出てきました。
やったことそこで、ある位置から特定の距離範囲にある店舗を持つサービスを地図上に表示して、そこからサービスを選べる機能を実装しました。
具体的には、以下のように地図上に店舗がピンで表示され、クリックするとその店舗を持つサービスの詳細を見ることができます。
このような機能がGoogle MapとElasticsearchを使って思った以上に簡単に実現できたので、今回はその方法について説明します。
どうやって実現したかここからは実際にどのように実現したかについて書いていきます。
途中で一部RailsやReactのサンプルコードが出てきますが、基本的にはどの言語でも参考になるよう特定の言語に依存しない形で説明しています。
全体の流れ実現するために必要な全体の流れとしては以下の通りです。
店舗住所からGoogle MapのGeocoding APIを用いて緯度経度を取得緯度経度をElasticsearchに連携Elasticsearchのgeo-distance queryを用いて距離検索を実行Google MapのMaps JavaScript APIを用いて地図を表示大きくは、 緯度経度の取得・保存(1, 2) と 距離検索・地図の描画(3, 4) の部分に分けられるので、それぞれのシーケンス図を記載します。
緯度経度の取得・保存 距離検索・地図の描画ざっくりと全体の流れがイメージできたところで、ここからは1〜4についてそれぞれもう少し詳しく解説していきます。
1. 店舗住所からGoogle MapのGeocoding APIを用いて緯度経度を取得まずは緯度経度の取得・保存の部分です。
前提として、弊社のデータチームが各サービスの店舗・施設の住所情報は入力してくれます。なので、入力された住所の保存時にGoogle MapのGeocoding APIを叩いて住所から緯度経度情報を取得します。
リクエスト例以下のエンドポイントにURLエンコーディングした住所とAPIキーをパラメータとして付与してリクエストします。
https://maps.googleapis.com/maps/api/geocode/json?address=%E6%9D%B1%E4%BA%AC%E9%83%BD%E4%B8%AD%E5%A4%AE%E5%8C%BA%E7%AF%89%E5%9C%B07-17-1&key= レスポンス例以下のようなレスポンスが返ってくるので、レスポンスの中の ['geometry']['location'] に含まれている緯度経度情報を抜き出します。
{"results" : [ { ..., "geometry" :{...,"location" : {"lat" : 35.6655524,"lng" : 139.7761508},..., } }],"status" : "OK"}各言語でクライアントライブラリが用意されていたりするのでそれを使ってもよいですが、今回はそこまで複雑な処理でもなかったので自前でリクエストとレスポンスをparseする処理を実装しました。
さらに詳細を知りたい方は以下の公式ドキュメントを参照ください。https://developers.google.com/maps/documentation/geocoding/requests-geocoding?hl=ja
2. 緯度経度情報をElasticsearchに連携1.で取得した緯度経度をDBとElasticsearchに保存します。
ちなみに行政の事務利用のためのガイドラインによると緯度経度は小数点以下6桁まであれば10cm程度の誤差で位置を特定することができるらしいです。(知らなかった!)なので、小数点以下6〜7桁程度の精度で保存しておけばよさそうです。
Elasticsearchでは geo_point というフィールドタイプが用意されているので、位置情報を扱う場合はそれを使うと便利です。
geo_point 型はdynamic mappingでは検出されないので、事前にインデックスのマッピング設定を行います。
PUT product_location_index{ "mappings": {"properties": { "location": {"type": "geo_point" }} }}データ登録の際は geo_point 型のフィールドに緯度経度を渡します。
PUT product_location_index/_doc/12345{ "location": {"lat": "35.6655524","lon":"139.7761508" }}ちなみに緯度経度は配列の形式でも渡せるのですが、以下のように緯度と経度が逆になるので注意が必要です。
{ "location": [139.7761508, 35.6655524] }他にも指定方法があるので、詳しく知りたい方は以下のドキュメントを参照してみてください。https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-point.html
ちなみに、Railsの場合はelasticsearch-railsのgemを使って以下のようなコードをモデルなりConcernなりに定義してあげればOKです。(他に必要なフィールドは適宜追加してください)
settings do mappings dynamic: false doindexes :location, type: :geo_point endenddef as_indexed_json(*) index = { location: { lat: latitude, lon: longitude } } index.as_jsonendafter_create_commit do __elasticsearch__.index_document if latitude.present? && longitude.present?endafter_update_commit do if latitude.present? && longitude.present?__elasticsearch__.update_document else__elasticsearch__.delete_document endendafter_destroy_commit do __elasticsearch__.delete_documentend 3. Elasticsearchのgeo-distance queryを用いて距離検索を実行続いて、今度はページ表示の部分です。
ここでは、Elasticsearchのgeo-distance queryで円形距離検索を実行します。
geo-distance queryは中心の緯度経度とそこからの距離を渡してあげると、中心から指定の半径内に含まれるデータを絞り込んでくれます。
以下の例は築地駅から半径1km圏内のデータを絞り込む例です。
GET product_location_index/_search{ "query": {"geo_distance": { "distance": "1km", "location": {"lat": 35.6680497,"lon": 139.7725658 }} }}今回の用途では円形のgeo-distance queryを用いましたが、他にも矩形なども選択できるので、詳しく知りたい方はドキュメントを参照してみてください。また、距離順にソートしたり、中心位置からの距離を取得したりも簡単にできます。https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html
4. Google MapのMaps JavaScript APIを用いて地図を表示最後は、3.で絞り込んだデータをFrontendに渡して地図を表示します。
今回はGoogle MapのMaps JavaScript APIを用いて地図の表示を実装しました。Maps JavaScript APIはWebアプリケーション向けに動的でインタラクティブな地図機能を提供してくれ、高度にカスタマイズすることができます。
弊社ではFrontendではReactを使っているので、Maps JavaScript APIのReact向けのライブラリであるreact-google-mapsを使用して実装を行いました。他にもreact-wrapperという公式のReactラッパーもあったのですが、以下の理由からreact-google-mapsを採用しました。
react-wrapperに比べてreact-google-mapsの方が直近も頻繁にメンテナンスされているreact-wrapperに比べてreact-google-mapsの方が後発にも関わらずStar数が多いreact-google-mapsもGoogleが公式にサポートしている両方触ってみた感じ、後発なこともありreact-google-mapsの方が使い勝手がよい以下の記事でGoogleが公式に紹介もしています。https://cloud.google.com/blog/ja/products/maps-platform/introducing-react-components-for-the-maps-javascript-api
以下はサンプルから必要最低限の箇所を抜粋したコードですが、こんな感じで書いてあげるだけで簡単にサイト上に地図機能とカスタマイズしたマーカーを表示することができます。
👀あとは必要なデータをBackendから渡して、上記のコードを自分たちの実現したい仕様に合わせて改修してあげればOKです。
公式ドキュメントで使用例をいい感じにまとめてくれているので、以下のページの挙動とコードを見比べてみると使いたい機能が簡単に実装できます。https://visgl.github.io/react-google-maps/examples/markers-and-infowindows
ここまでで冒頭で紹介した機能が実装できました。
検討したこと実際に開発する中で検討したこともいくつか紹介したいと思います。今後似たような機能を実装される方の参考になれば幸いです。
商品のインデックスにフィールドを追加するか新規で店舗のインデックスを作成するか今回商品に対して店舗情報が複数紐付くというデータ構造だったため、元々Elasticsearchにインデックスしていた商品情報に店舗の位置情報も配列で持たせるか、新規で店舗情報用のインデックスを作成するか迷いました。
現状の仕様だとどちらでも問題ないのですが、今後の展望として中心位置(例:渋谷駅)からの距離を表示したり、近い順にソートしたりといった要望があり、既存の商品のインデックスにフィールドを追加する形だとそれが実現できないため、今回は後者を選択しました。
無停止でインデックスの再構築ができるようエイリアスを利用詳しく書くと長くなるので詳細は割愛しますが、Elasticsearchにおいて無停止でインデックスの再構築ができるようエイリアスを利用するようにしました。
サービスを運用していると、マッピングやアナライザーの定義を変更したいケースが度々発生します。その際にもサービスを停止せずにインデックスの更新が行えるように、インデックスを直に指定するのではなく、エイリアスを使って指定するようにしました。
以下のクックパッドさんの記事がわかりやすいので、これからElasticsearchを導入される方はぜひ参考にしてみてください。https://techlife.cookpad.com/entry/2015/09/25/170000
地図描画にGoogle MapのどのAPIを利用するかGoogle Mapで地図を描画する際には主に以下の3つのAPIがあります。
①Maps Static API②Maps Embed API③Maps JavaScript API説明静的なMapを画像で埋め込む動的なMapをiframeで埋め込むカスタマイズしたMapをJSで埋め込むMapを動かせる❌✅✅複数ピンを立てる✅❌✅ピンのカスタム⚠(英字1文字は可)❌✅独自カード表示❌❌✅実装工数小小大50万リクエスト時の費用$840無料$3,000それぞれの選択肢でできること/できないこと・コストを比較して、今回の用途に合ったMaps JavaScript APIを選択しました。
まとめ今回は、Google MapのAPIとElasticsearchを使って位置情報検索を実装した話について書きました。
Google MapとElasticsearchのおかげで、地図から自分に合った商品(サービス)が探せる機能が簡単に実現できました。
今後の展望としては、ユーザーニーズに応じてではありますが、中心位置からの距離を表示したり、近い順にソートしたり、ユーザーが任意の距離で絞り込みをできるようにしたりといった機能の追加を検討できるとよさそうです。
この記事が今後位置情報検索を開発される方の何かしら参考になれば幸いです。
また、弊社は絶賛エンジニア募集中なので、もしご興味を持った方がいましたらカジュアルにお話できると嬉しいです!https://mybestcom.notion.site/mybest-information-for-Engineers-8beadd9c91ef4dc2b21171d48a4b0c49